Skip to content

add 3-signal rotation, recording lock, and debug layout readout - #643

Draft
Kimblebee wants to merge 13 commits into
kim/adaptive/preview-display-framingfrom
kim/adaptive/debug-layout-readout
Draft

Kimblebee wants to merge 13 commits into
kim/adaptive/preview-display-framingfrom
kim/adaptive/debug-layout-readout

Conversation

@Kimblebee

@Kimblebee Kimblebee commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked PR: Based on kim/adaptive/preview-display-framing #650

Note on DebugLayoutReadout: The on-screen layout readout in this PR is currently being used as a hands-on tool to help align window-size thresholds, form-factor buckets, and rotation behavior across different devices—expect potentially significant changes to the readout and its classification logic as device alignment continues.

Summary

Now that kim/adaptive/preview-display-framing unlocks MainActivity orientation on large screens and multi-window panes, physical hardware gravity (deviceRotation) and OS display posture (windowRotation) can vary independently. This PR introduces the 3-signal rotation foundation (deviceRotation, windowRotation, and in-window gravityRotationDegrees), draw- and layout-phase rotation modifiers (animateToUpright, rotateLayoutToGravity), mid-recording orientation locking in CameraSessionEffect, and DebugLayoutReadout in :ui:debug (Layout, Mode, Form Factor, Device Rotation, Window Rotation, Expected Layout Rotation, and Icon Rotation).

Acknowledgement: Adapts DeviceRotation orientation helpers, RotationModifiers.kt (rememberWindowRotation display listener, shortest-path upright animation, animateToUpright, and constraint-swapping layout rotation), and the video-recording orientation lock from PR #619, generalizing them from a fixed-portrait frame into the 3-signal relative model (deviceRotation, windowRotation, gravity delta).

Key Changes

  • DebugOverlayComponents.kt, TestTags.kt, strings.xml, ui/debug/build.gradle.kts (:ui:debug):
    • Adds DebugWindowOrientation, 7-mode hysteresis-stabilized DebugFormFactor (±7dp deadbands), DebugLayoutReadoutState, DebugLayoutReadout, and an animated gravity-pointing DebugUpwardTriangleIndicator to DebugConsole, with individual test tags for each line. Uses live LocalWindowInfo.current.containerSize dimensions by default when explicit state is omitted, and hides when debugHidingComponents is enabled.
    • Enables Android resources in unit tests and adds Robolectric Compose test dependencies for :ui:debug.
  • DeviceRotation.kt (:core:model):
    • Adds DeviceRotation.toClockwiseRotationDegrees(), DeviceRotation.toGravityRotationDegrees(windowRotation) (clamping 180° deltas to 0°), toWindowCounterRotationDegrees(), and isLandscape.
  • RotationModifiers.kt, DebouncedOrientationFlow.kt, CaptureScaffold.kt, CaptureLayout.kt, PreviewScreen.kt (:ui:components:capture, :feature:preview):
    • Adds LocalDeviceRotation, LocalWindowRotation, LocalGravityRotationState, rememberWindowRotation() (LocalConfiguration + DisplayManager.DisplayListener to catch seamless 180° landscape flips), rememberDeviceRotation(), rememberGravityRotationState() (shortest-path 250ms tween on 90° quarter-turns by default via animateQuarterTurns = true; instant snap() on 180° flips, window-rotation changes, or when animateQuarterTurns = false), draw-phase Modifier.animateToUpright(), and layout-phase Modifier.rotateLayoutToGravity().
    • Threads deviceRotation and windowRotation through PreviewScreen -> CaptureScaffold -> CaptureScaffoldLayout, and enforces symmetric ±5° hysteresis around candidate quadrant centers in debouncedOrientationFlow.
  • CameraSessionEffect.kt, PreviewScreen.kt (Recording Orientation Lock):
    • While video recording is active (videoRecordingState !is VideoRecordingState.Inactive), locks the host Activity to SCREEN_ORIENTATION_LOCKED and suppresses live sensor rotation updates, restoring the previous requestedOrientation and flushing any buffered sensor rotation when recording ends.

How Rotation & Posture Math Works (and Resolves Single-Signal Limitations)

Phone-only landscape logic derives UI rotation from deviceRotation alone (assuming the Activity window is permanently locked to 0°). Once the window itself can rotate (on foldables, tablets, or in multi-window split-screen):

  • Single-signal limitation: If both the physical device and the Activity window rotate 90°, rotating icons by deviceRotation (90°) double-rotates them sideways relative to the already-rotated window. Conversely, if a split-screen window is rotated 90° while the user holds the device at 0°, deviceRotation (0°) fails to counter-rotate the icons.
  • 3-signal gravity delta (toGravityRotationDegrees): DeviceRotation.fromUiSurfaceRotation maps Surface.ROTATION_90 -> Rotated270 and Surface.ROTATION_270 -> Rotated90 so windowRotation shares deviceRotation's physical clockwise frame. toGravityRotationDegrees then computes ((windowDeg - deviceDeg) % 360 + 360) % 360 so icons only counter-rotate by the residual delta between the window and physical gravity (clamping 180° to 0° so icons and layouts do not flip upside-down, while CameraX still receives DeviceRotation.Rotated180 for capture metadata).
  • Draw-phase vs. layout-phase rotation & no double-animation wobble: Modifier.animateToUpright() reads State<Float>.value strictly inside graphicsLayer (RenderNode-only invalidation, zero recomposition/re-layout). Modifier.rotateLayoutToGravity() swaps bounded Constraints axes and outer (width, height) at 90°/270° and centers via placeWithLayer so rectangular surfaces neither clip nor misalign hit targets. rememberGravityRotationState animates 90° quarter-turns over 250ms by default (animateQuarterTurns = true) while snapping immediately on 180° half-turn flips (90° <-> 270°) and whenever windowRotation changes so icons do not animate inside an already-rotating OS window.

Form Factor & Expected Layout Rotation Table (DebugFormFactor.fromWindowDp)

Let minDim = min(widthDp, heightDp), maxDim = max(widthDp, heightDp), and ratio = maxDim / minDim (with ±7dp hysteresis around previous when dragged across a boundary):

Form Factor Dimension Condition (fromWindowDp) Tall (H >= W) Expected Layout Rotation Wide (W > H) Expected Layout Rotation
UNKNOWN widthDp <= 0 || heightDp <= 0 0° 0°
COMPACT_SHORT_SLIM widthDp < 480dp && heightDp < 480dp && minDim < 360dp 0° (condensed side rails) 0° (condensed side rails)
COMPACT_SHORT widthDp < 480dp && heightDp < 480dp (minDim >= 360dp) 0° (condensed side rails) 0° (condensed side rails)
COMPACT_SLIM minDim < 360dp (and maxDim >= 480dp) 0° 90° or 270°
COMPACT widthDp < 600dp || heightDp < 480dp (minDim >= 360dp, maxDim >= 480dp) 0° 90° or 270°
FOLDABLE widthDp >= 600dp && heightDp >= 480dp && maxDim < 960dp && ratio < 1.45f 0° (upright; side rails adapt) 0° (upright; side rails adapt)
TABLET_PORTRAIT widthDp >= 600dp && heightDp >= 480dp (non-foldable) and heightDp >= widthDp 0° (upright; side rails adapt) —
TABLET_LANDSCAPE widthDp >= 600dp && heightDp >= 480dp (non-foldable) and widthDp > heightDp — 0° (upright; side rails adapt)

(For wide COMPACT / COMPACT_SLIM postures, expectedLayoutRotationDegrees uses iconRotationDegrees when 90°/270°, falls back to the window counter-rotation when windowRotation is 90°/270°, and defaults to 270° so controls sit on the right edge.)

Bugs Fixed Along the Way

  • DebugTextBar duplicate modifier application (DebugOverlayComponents.kt): DebugTextBar previously passed its modifier parameter to the outer Row and again to both child Text views, applying any padding, sizing, or testTag three times.
  • Non-adjacent (180°) quadrant boundary jitter (DebouncedOrientationFlow.kt): Checking distance to candidate center (<= 40°) rather than distance from prevSnap (>= 50°) ensures the ±5° deadband holds across all four quadrant boundaries.
  • Mid-recording rotation drift (CameraSessionEffect.kt): Prevents sensor setDisplayRotation updates and OS window orientation changes from firing mid-recording, while guaranteeing stale orientation is flushed once recording stops.

Not Included Yet

  • Comes later in this series:
    • Whole-layout .counterRotateToPortrait, inset/cutout remapping, and live CameraLayoutMode readout wiring (kim/adaptive/layout-rotation).
    • CameraX Preview.targetRotation and ViewPort synchronization with windowRotation (kim/adaptive/viewfinder-rotation).
    • Per-control .animateToUpright() / .rotateLayoutToGravity() adoption and sticky 180° gravity retention (kim/adaptive/content-gravity).

Testing

  • :core:model:testDebugUnitTest — DeviceRotationTest (all 16 deviceRotation × windowRotation combinations, Surface rotation round-trip, 180° clamp, counter-rotation angles).
  • :ui:components:capture:testDebugUnitTest — RotationModifiersTest (shortest-path 0° <-> 270° unwrapping, animateQuarterTurns = true default tween with 180° flip snap, animateQuarterTurns = false immediate snap, animateToUpright, rotateLayoutToGravity 90°/270° dimension swap) and CaptureScaffoldTest (CameraSessionEffect recording orientation lock and restore).
  • :ui:debug:testDebugUnitTest — DebugLayoutReadoutTest (7-mode DebugFormFactor.fromWindowDp classification and ±7dp hysteresis, expectedLayoutRotationDegrees including 550x500dp wide COMPACT vs. <480dp COMPACT_SHORT, per-field test tags, single-node DebugTextBar modifier scoping, and debugHidingComponents hiding).

Add DebugLayoutReadoutState and internal DebugLayoutReadout composable
to display window container size in dp, display rotation degrees,
rotation-lock status, and active adaptive layout mode in DebugConsole,
falling back to live LocalWindowInfo container dimensions when explicit
posture state is omitted.

Also fix DebugTextBar applying its outer Row modifier three times to its
child Text nodes, configure Robolectric Compose unit testing for
:ui:debug, and add unit tests covering explicit/default readouts, live
window fallback, modifier scoping, and hidden-component behavior.
…yout-readout

# Conflicts:
#	ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt
…/debug-layout-readout

# Conflicts:
#	feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an adaptive portrait layout solver and updates the capture screen UI to support adaptive layout and rotation behaviors across various device form factors, alongside a new microbenchmark module to measure layout solver performance. The review feedback highlights a critical compilation error in PreviewScreen.kt regarding the usage of repeatOnLifecycle, and suggests a more robust recursive unwrapping method to safely retrieve the Activity from the context. Additionally, the feedback points out that two new test classes violate the repository style guide by omitting the @RunWith annotation.

Comment on lines +285 to +287
LaunchedEffect(lifecycleOwner, context) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
debouncedOrientationFlow(context, initialRotation = windowRotation).collect {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The repeatOnLifecycle extension function is defined on Lifecycle, not LifecycleOwner. Calling it directly on lifecycleOwner will cause a compilation error. It should be called on lifecycleOwner.lifecycle instead.

Suggested change
LaunchedEffect(lifecycleOwner, context) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
debouncedOrientationFlow(context, initialRotation = windowRotation).collect {
LaunchedEffect(lifecycleOwner, context) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
debouncedOrientationFlow(context, initialRotation = windowRotation).collect {

}
}

val activity = context as? Activity

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Casting context as? Activity directly can fail if the context is wrapped in a ContextWrapper (such as Hilt's ViewComponentManager.FragmentContextWrapper or a ContextThemeWrapper). To ensure the activity is correctly resolved and the orientation can be locked during video recording, recursively unwrap the context to find the underlying Activity.

    val activity = remember(context) {
        var c = context
        while (c is android.content.ContextWrapper) {
            if (c is Activity) return@remember c
            c = c.baseContext
        }
        c as? Activity
    }

Comment on lines +20 to +25
import org.junit.Test

/**
* Unit tests for [resolveRequestedOrientation].
*/
class MainActivityOrientationTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, all test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. Since this test uses Android-specific classes like ActivityInfo, it should be run with RobolectricTestRunner.

import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

/**
 * Unit tests for [resolveRequestedOrientation].
 */
@RunWith(RobolectricTestRunner::class)
class MainActivityOrientationTest {
References
  1. All test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. (link)

Comment on lines +18 to +22
import android.view.Surface
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class DeviceRotationTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, all test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. Since this test uses Android-specific classes like Surface, it should be run with RobolectricTestRunner.

import android.view.Surface
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class DeviceRotationTest {
References
  1. All test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. (link)

Kimblebee and others added 4 commits November 9, 2026 19:02
…/debug-layout-readout

# Conflicts:
#	core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt
#	feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt
…on, active layout readout, and recording orientation lock
@Kimblebee Kimblebee changed the title Kim/adaptive/debug layout readout add 3-signal rotation, recording lock, and debug layout readout Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant